313 lines
12 KiB
JavaScript
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');
|
|
});
|