/** * StockChart - TradingView Lightweight Charts 기반 주식 차트 * 틱(기본) / 일봉 / 1분봉 / 5분봉 전환, 실시간 업데이트 지원 */ let candleChart = null; // 캔들 차트 인스턴스 (lazy) let candleSeries = null; // 캔들스틱 시리즈 let maSeries = {}; // 이동평균선 시리즈 { 5, 20, 60 } let tickChart = null; // 틱 차트 인스턴스 let tickSeries = null; // 틱 라인 시리즈 let currentPeriod = 'minute1'; // 틱 데이터 버퍼 const tickBuffer = []; // 공통 차트 옵션 (autoSize로 컨테이너 크기 자동 추적) const CHART_BASE_OPTIONS = { autoSize: true, layout: { background: { color: '#ffffff' }, textColor: '#374151', }, grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' }, }, crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, rightPriceScale: { borderColor: '#e5e7eb' }, timeScale: { borderColor: '#e5e7eb', timeVisible: true, }, }; // 틱 차트 초기화 function initTickChart() { const container = document.getElementById('tickChartContainer'); if (!container || tickChart) return; tickChart = LightweightCharts.createChart(container, CHART_BASE_OPTIONS); tickSeries = tickChart.addLineSeries({ color: '#6366f1', lineWidth: 2, crosshairMarkerVisible: true, lastValueVisible: true, priceLineVisible: false, }); if (tickBuffer.length > 0) { tickSeries.setData([...tickBuffer]); tickChart.timeScale().fitContent(); } } // SMA 계산: closes 배열과 period를 받아 [{time, value}] 반환 function calcSMA(candles, period) { const result = []; for (let i = period - 1; i < candles.length; i++) { let sum = 0; for (let j = i - period + 1; j <= i; j++) sum += candles[j].close; result.push({ time: candles[i].time, value: Math.round(sum / period) }); } return result; } // 캔들 차트 lazy 초기화 function ensureCandleChart() { if (candleChart) return; const container = document.getElementById('chartContainer'); if (!container) return; candleChart = LightweightCharts.createChart(container, CHART_BASE_OPTIONS); candleSeries = candleChart.addCandlestickSeries({ upColor: '#ef4444', downColor: '#3b82f6', borderUpColor: '#ef4444', borderDownColor: '#3b82f6', wickUpColor: '#ef4444', wickDownColor: '#3b82f6', }); // 이동평균선 시리즈 추가 (20분/60분/120분) maSeries[20] = candleChart.addLineSeries({ color: '#f59e0b', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false }); maSeries[60] = candleChart.addLineSeries({ color: '#10b981', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false }); maSeries[120] = candleChart.addLineSeries({ color: '#8b5cf6', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false }); } // 캔들 데이터 로딩 + 이동평균선 계산 async function loadChart(period) { if (!STOCK_CODE) return; const endpoint = period === 'daily' ? `/api/stock/${STOCK_CODE}/chart` : `/api/stock/${STOCK_CODE}/chart?period=${period}`; try { const resp = await fetch(endpoint); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const candles = await resp.json(); if (!candles || candles.length === 0) return; const data = candles.map(c => ({ time: c.time, open: c.open, high: c.high, low: c.low, close: c.close, })); candleSeries.setData(data); // 이동평균선 업데이트 [20, 60, 120].forEach(n => { if (maSeries[n]) maSeries[n].setData(calcSMA(data, n)); }); candleChart.timeScale().fitContent(); } catch (err) { console.error('차트 데이터 로딩 실패:', err); } } // 탭 UI 업데이트 function updateTabUI(period) { ['daily', 'minute1', 'minute5', 'tick'].forEach(p => { const tab = document.getElementById(`tab-${p}`); if (!tab) return; tab.className = p === period ? 'px-4 py-1.5 text-sm rounded-full bg-blue-500 text-white font-medium' : 'px-4 py-1.5 text-sm rounded-full bg-gray-100 text-gray-600 font-medium hover:bg-gray-200'; }); } // 탭 전환 function switchChart(period) { currentPeriod = period; updateTabUI(period); const candleEl = document.getElementById('chartContainer'); const tickEl = document.getElementById('tickChartContainer'); if (period === 'tick') { if (candleEl) candleEl.style.display = 'none'; if (tickEl) tickEl.style.display = 'block'; if (!tickChart) initTickChart(); if (tickSeries && tickBuffer.length > 0) { tickSeries.setData([...tickBuffer]); tickChart.timeScale().fitContent(); } } else { if (tickEl) tickEl.style.display = 'none'; if (candleEl) candleEl.style.display = 'block'; ensureCandleChart(); loadChart(period); } } // WebSocket 체결 수신 시 틱 버퍼에 추가 function appendTick(price) { if (!price.currentPrice) return; const now = Math.floor(Date.now() / 1000); const point = { time: now, value: price.currentPrice }; if (tickBuffer.length > 0 && tickBuffer[tickBuffer.length - 1].time === now) { tickBuffer[tickBuffer.length - 1].value = price.currentPrice; } else { tickBuffer.push(point); if (tickBuffer.length > 1000) tickBuffer.shift(); } if (currentPeriod === 'tick' && tickSeries) { try { tickSeries.update(point); } catch (_) {} } } // 실시간 현재가로 마지막 캔들 업데이트 function updateLastCandle(price) { if (!candleSeries || !price.currentPrice) return; const now = Math.floor(Date.now() / 1000); try { candleSeries.update({ time: now, open: price.open || price.currentPrice, high: price.high || price.currentPrice, low: price.low || price.currentPrice, close: price.currentPrice, }); } catch (_) {} } // 체결시각 포맷 (HHMMSS → HH:MM:SS) function formatTradeTime(t) { if (!t || t.length < 6) return '-'; return `${t.slice(0,2)}:${t.slice(2,4)}:${t.slice(4,6)}`; } // 거래대금 포맷 function formatTradeMoney(n) { if (!n) return '-'; if (n >= 1000000000000) return (n / 1000000000000).toFixed(2) + '조'; if (n >= 100000000) return (n / 100000000).toFixed(1) + '억'; if (n >= 10000) return Math.round(n / 10000) + '만'; return n.toLocaleString('ko-KR'); } // 현재가 DOM 업데이트 function updatePriceUI(price) { const priceEl = document.getElementById('currentPrice'); const changeEl = document.getElementById('changeInfo'); const updatedAtEl = document.getElementById('updatedAt'); const highEl = document.getElementById('highPrice'); const lowEl = document.getElementById('lowPrice'); const volumeEl = document.getElementById('volume'); if (!priceEl) return; const prevPrice = parseInt(priceEl.dataset.raw || '0'); const isUp = price.currentPrice > prevPrice; const isDown = price.currentPrice < prevPrice; priceEl.textContent = formatNumber(price.currentPrice) + '원'; priceEl.dataset.raw = price.currentPrice; const sign = price.changePrice >= 0 ? '+' : ''; changeEl.textContent = `${sign}${formatNumber(price.changePrice)}원 (${sign}${price.changeRate.toFixed(2)}%)`; changeEl.className = price.changeRate > 0 ? 'text-lg mt-1 text-red-500' : price.changeRate < 0 ? 'text-lg mt-1 text-blue-500' : 'text-lg mt-1 text-gray-500'; if (highEl) highEl.textContent = formatNumber(price.high) + '원'; if (lowEl) lowEl.textContent = formatNumber(price.low) + '원'; if (volumeEl) volumeEl.textContent = formatNumber(price.volume); const tradeTimeEl = document.getElementById('tradeTime'); if (tradeTimeEl && price.tradeTime) tradeTimeEl.textContent = formatTradeTime(price.tradeTime); const tradeVolEl = document.getElementById('tradeVolume'); if (tradeVolEl && price.tradeVolume) tradeVolEl.textContent = formatNumber(price.tradeVolume); const tradeMoneyEl = document.getElementById('tradeMoney'); if (tradeMoneyEl) tradeMoneyEl.textContent = formatTradeMoney(price.tradeMoney); const ask1El = document.getElementById('askPrice1'); const bid1El = document.getElementById('bidPrice1'); if (ask1El && price.askPrice1) ask1El.textContent = formatNumber(price.askPrice1) + '원'; if (bid1El && price.bidPrice1) bid1El.textContent = formatNumber(price.bidPrice1) + '원'; if (updatedAtEl) { const d = new Date(price.updatedAt); updatedAtEl.textContent = `${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')} 기준`; } if (isUp) { priceEl.classList.remove('flash-down'); void priceEl.offsetWidth; priceEl.classList.add('flash-up'); } else if (isDown) { priceEl.classList.remove('flash-up'); void priceEl.offsetWidth; priceEl.classList.add('flash-down'); } updateLastCandle(price); appendTick(price); } // 장운영 상태 업데이트 function updateMarketStatus(ms) { const el = document.getElementById('marketStatusBadge'); if (!el) return; el.textContent = ms.statusName || '장 중'; const code = ms.statusCode; el.className = 'px-2 py-0.5 rounded text-xs font-medium '; if (code === '3') el.className += 'bg-green-100 text-green-700'; else if (['4','8','9'].includes(code)) el.className += 'bg-gray-100 text-gray-600'; else if (['a','b','c','d'].includes(code)) el.className += 'bg-yellow-100 text-yellow-700'; else el.className += 'bg-blue-50 text-blue-600'; } // 종목 메타 업데이트 function updateStockMeta(meta) { const upperEl = document.getElementById('upperLimit'); const lowerEl = document.getElementById('lowerLimit'); const baseEl = document.getElementById('basePrice'); if (upperEl && meta.upperLimit) upperEl.textContent = formatNumber(meta.upperLimit) + '원'; if (lowerEl && meta.lowerLimit) lowerEl.textContent = formatNumber(meta.lowerLimit) + '원'; if (baseEl && meta.basePrice) baseEl.textContent = formatNumber(meta.basePrice) + '원'; } // 숫자 천 단위 콤마 포맷 function formatNumber(n) { return Math.abs(n).toLocaleString('ko-KR'); } // DOMContentLoaded: WebSocket만 먼저 연결 document.addEventListener('DOMContentLoaded', () => { if (typeof STOCK_CODE === 'undefined' || !STOCK_CODE) return; stockWS.subscribe(STOCK_CODE); stockWS.onPrice(STOCK_CODE, updatePriceUI); stockWS.onOrderBook(STOCK_CODE, renderOrderBook); stockWS.onProgram(STOCK_CODE, renderProgram); stockWS.onMeta(STOCK_CODE, updateStockMeta); stockWS.onMarket(updateMarketStatus); initOrderBook(); updateTabUI('minute1'); }); // window.load: 레이아웃 완전 확정 후 차트 초기화 window.addEventListener('load', () => { if (typeof STOCK_CODE === 'undefined' || !STOCK_CODE) return; // 1분봉 캔들 차트를 기본으로 초기화 const candleEl = document.getElementById('chartContainer'); if (candleEl) candleEl.style.display = 'block'; const tickEl = document.getElementById('tickChartContainer'); if (tickEl) tickEl.style.display = 'none'; ensureCandleChart(); loadChart('minute1'); });