Files
stocksearch/static/js/chart.js
2026-03-31 19:32:59 +09:00

313 lines
12 KiB
JavaScript

/**
* 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');
});