first commit
This commit is contained in:
312
static/js/chart.js
Normal file
312
static/js/chart.js
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
Reference in New Issue
Block a user