- Add loadDailyOpenPrice() to fetch daily candle open price - Change price change calculation from previous candle to KST 09:00 basis - Binance daily candle starts at UTC 00:00 (= KST 09:00) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1064 lines
42 KiB
JavaScript
1064 lines
42 KiB
JavaScript
// Bitcoin Trading View Application
|
|
|
|
class TradingView {
|
|
constructor() {
|
|
this.symbol = 'BTCUSDT';
|
|
this.timeframe = '15m';
|
|
this.candleData = [];
|
|
this.ws = null;
|
|
this.charts = {};
|
|
this.series = {};
|
|
this.signals = [];
|
|
this.gcDcMarkers = [];
|
|
this.showGCDC = false;
|
|
this.dailyOpenPrice = null; // KST 09:00 기준 시가
|
|
this.dailyOpenTime = null; // 기준 시간
|
|
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
this.setupCharts();
|
|
this.setupEventListeners();
|
|
await this.loadDailyOpenPrice();
|
|
await this.loadHistoricalData();
|
|
this.connectWebSocket();
|
|
}
|
|
|
|
// KST 오전 9시 기준 시가를 가져오는 함수
|
|
async loadDailyOpenPrice() {
|
|
try {
|
|
// 현재 KST 시간 계산
|
|
const now = new Date();
|
|
const kstOffset = 9 * 60 * 60 * 1000; // UTC+9
|
|
const kstNow = new Date(now.getTime() + kstOffset);
|
|
|
|
// 오늘 KST 09:00 시간 계산
|
|
const kstToday9am = new Date(kstNow);
|
|
kstToday9am.setUTCHours(0, 0, 0, 0); // KST 09:00 = UTC 00:00
|
|
|
|
// 현재 시간이 KST 09:00 이전이면 어제 09:00 기준
|
|
if (kstNow.getUTCHours() < 0 || (kstNow.getUTCHours() === 0 && kstNow.getUTCMinutes() === 0)) {
|
|
kstToday9am.setUTCDate(kstToday9am.getUTCDate() - 1);
|
|
}
|
|
|
|
// UTC 기준으로 변환 (KST 09:00 = UTC 00:00)
|
|
const startTime = kstToday9am.getTime() - kstOffset;
|
|
|
|
// 바이낸스 1일봉 데이터 가져오기 (limit=2로 오늘과 어제)
|
|
const url = `https://api.binance.com/api/v3/klines?symbol=${this.symbol}&interval=1d&limit=2`;
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
if (data && data.length > 0) {
|
|
// 가장 최근 1일봉의 시가 (UTC 00:00 = KST 09:00 기준)
|
|
const latestCandle = data[data.length - 1];
|
|
this.dailyOpenPrice = parseFloat(latestCandle[1]); // Open price
|
|
this.dailyOpenTime = new Date(latestCandle[0]); // Open time (UTC)
|
|
|
|
console.log('Daily open price (KST 09:00):', this.dailyOpenPrice);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading daily open price:', error);
|
|
}
|
|
}
|
|
|
|
setupCharts() {
|
|
const chartOptions = {
|
|
layout: {
|
|
background: { type: 'solid', color: '#1e222d' },
|
|
textColor: '#d1d4dc',
|
|
},
|
|
grid: {
|
|
vertLines: { color: '#2a2e39' },
|
|
horzLines: { color: '#2a2e39' },
|
|
},
|
|
crosshair: {
|
|
mode: LightweightCharts.CrosshairMode.Normal,
|
|
},
|
|
rightPriceScale: {
|
|
borderColor: '#2a2e39',
|
|
scaleMargins: {
|
|
top: 0.1,
|
|
bottom: 0.1,
|
|
},
|
|
autoScale: true,
|
|
},
|
|
timeScale: {
|
|
borderColor: '#2a2e39',
|
|
timeVisible: true,
|
|
secondsVisible: false,
|
|
barSpacing: 8,
|
|
minBarSpacing: 2,
|
|
rightOffset: 5,
|
|
tickMarkFormatter: (timestamp, tickMarkType, locale) => {
|
|
const date = new Date(timestamp * 1000);
|
|
const options = { timeZone: 'Asia/Seoul' };
|
|
|
|
const month = String(date.toLocaleString('ko-KR', { ...options, month: 'numeric' })).replace('월', '').padStart(2, '0');
|
|
const day = String(date.toLocaleString('ko-KR', { ...options, day: 'numeric' })).replace('일', '').padStart(2, '0');
|
|
const hours = String(date.toLocaleString('ko-KR', { ...options, hour: 'numeric', hour12: false })).replace('시', '').padStart(2, '0');
|
|
const minuteStr = String(date.toLocaleString('ko-KR', { ...options, minute: 'numeric' })).replace('분', '').padStart(2, '0');
|
|
const minuteNum = parseInt(minuteStr, 10);
|
|
|
|
// For minute-based timeframes, show only 15-minute intervals
|
|
const isMinuteTimeframe = ['1m', '5m', '15m'].includes(this.timeframe);
|
|
|
|
if (isMinuteTimeframe) {
|
|
// Show date at midnight (00:00)
|
|
if (hours === '00' && minuteNum === 0) {
|
|
return `${month}/${day}`;
|
|
}
|
|
// Show date + time at 15-minute intervals
|
|
if (minuteNum % 15 === 0) {
|
|
return `${month}/${day} ${hours}:${minuteStr}`;
|
|
}
|
|
return '';
|
|
} else if (this.timeframe === '1h' || this.timeframe === '4h') {
|
|
// For hour timeframes, show date + time
|
|
return `${month}/${day} ${hours}:${minuteStr}`;
|
|
} else {
|
|
// For day timeframe, show date only
|
|
return `${month}/${day}`;
|
|
}
|
|
},
|
|
},
|
|
localization: {
|
|
timeFormatter: (timestamp) => {
|
|
const date = new Date(timestamp * 1000);
|
|
return date.toLocaleString('ko-KR', {
|
|
timeZone: 'Asia/Seoul',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
},
|
|
},
|
|
};
|
|
|
|
// Main Chart (Candlestick + MA + Bollinger Bands)
|
|
this.charts.main = LightweightCharts.createChart(
|
|
document.getElementById('main-chart'),
|
|
{ ...chartOptions, height: 450 }
|
|
);
|
|
|
|
// Candlestick Series
|
|
this.series.candle = this.charts.main.addCandlestickSeries({
|
|
upColor: '#26a69a',
|
|
downColor: '#ef5350',
|
|
borderUpColor: '#26a69a',
|
|
borderDownColor: '#ef5350',
|
|
wickUpColor: '#26a69a',
|
|
wickDownColor: '#ef5350',
|
|
});
|
|
|
|
// Volume Series (at bottom of main chart)
|
|
this.series.volume = this.charts.main.addHistogramSeries({
|
|
color: '#26a69a',
|
|
priceFormat: {
|
|
type: 'volume',
|
|
},
|
|
priceScaleId: 'volume',
|
|
scaleMargins: {
|
|
top: 0.85,
|
|
bottom: 0,
|
|
},
|
|
});
|
|
|
|
// Configure volume price scale
|
|
this.charts.main.priceScale('volume').applyOptions({
|
|
scaleMargins: {
|
|
top: 0.85,
|
|
bottom: 0,
|
|
},
|
|
});
|
|
|
|
// Moving Average Series
|
|
this.series.ma5 = this.charts.main.addLineSeries({ color: '#FFEB3B', lineWidth: 2, title: 'MA5', lastValueVisible: false, priceLineVisible: false });
|
|
this.series.ma20 = this.charts.main.addLineSeries({ color: '#1565C0', lineWidth: 2, title: 'MA20', lastValueVisible: false, priceLineVisible: false });
|
|
this.series.ma60 = this.charts.main.addLineSeries({ color: '#00E676', lineWidth: 2, title: 'MA60', lastValueVisible: false, priceLineVisible: false });
|
|
this.series.ma120 = this.charts.main.addLineSeries({ color: '#FF5252', lineWidth: 2, title: 'MA120', visible: false, lastValueVisible: false, priceLineVisible: false });
|
|
this.series.ma200 = this.charts.main.addLineSeries({ color: '#E040FB', lineWidth: 2, title: 'MA200', visible: false, lastValueVisible: false, priceLineVisible: false });
|
|
|
|
// Bollinger Bands (Upper/Lower only, Middle is same as MA20)
|
|
this.series.bbUpper = this.charts.main.addLineSeries({
|
|
color: '#FF9800',
|
|
lineWidth: 1,
|
|
lineStyle: 2,
|
|
lastValueVisible: false,
|
|
priceLineVisible: false,
|
|
});
|
|
this.series.bbLower = this.charts.main.addLineSeries({
|
|
color: '#FF9800',
|
|
lineWidth: 1,
|
|
lineStyle: 2,
|
|
lastValueVisible: false,
|
|
priceLineVisible: false,
|
|
});
|
|
|
|
// Cross markers series
|
|
this.series.markers = this.series.candle;
|
|
|
|
// MACD Chart
|
|
const macdOptions = {
|
|
layout: chartOptions.layout,
|
|
grid: chartOptions.grid,
|
|
crosshair: chartOptions.crosshair,
|
|
rightPriceScale: {
|
|
...chartOptions.rightPriceScale,
|
|
mode: LightweightCharts.PriceScaleMode.Normal,
|
|
},
|
|
timeScale: chartOptions.timeScale,
|
|
localization: chartOptions.localization,
|
|
height: 150,
|
|
};
|
|
this.charts.macd = LightweightCharts.createChart(
|
|
document.getElementById('macd-chart'),
|
|
macdOptions
|
|
);
|
|
|
|
this.series.macdLine = this.charts.macd.addLineSeries({
|
|
color: '#2962ff',
|
|
lineWidth: 2,
|
|
title: 'MACD',
|
|
priceScaleId: 'right',
|
|
});
|
|
this.series.signalLine = this.charts.macd.addLineSeries({
|
|
color: '#ff6d00',
|
|
lineWidth: 2,
|
|
title: 'Signal',
|
|
priceScaleId: 'right',
|
|
});
|
|
this.series.histogram = this.charts.macd.addHistogramSeries({
|
|
color: '#26a69a',
|
|
title: 'Histogram',
|
|
priceScaleId: 'right',
|
|
});
|
|
|
|
// RSI Chart
|
|
this.charts.rsi = LightweightCharts.createChart(
|
|
document.getElementById('rsi-chart'),
|
|
{ ...chartOptions, height: 150 }
|
|
);
|
|
|
|
this.series.rsi = this.charts.rsi.addLineSeries({
|
|
color: '#FFEB3B',
|
|
lineWidth: 2,
|
|
title: 'RSI',
|
|
});
|
|
|
|
// RSI Overbought/Oversold lines (more visible)
|
|
this.series.rsiOverbought = this.charts.rsi.addLineSeries({
|
|
color: '#ef5350',
|
|
lineWidth: 2,
|
|
lineStyle: 2,
|
|
lastValueVisible: false,
|
|
priceLineVisible: false,
|
|
});
|
|
this.series.rsiOversold = this.charts.rsi.addLineSeries({
|
|
color: '#26a69a',
|
|
lineWidth: 2,
|
|
lineStyle: 2,
|
|
lastValueVisible: false,
|
|
priceLineVisible: false,
|
|
});
|
|
|
|
// Stochastic Chart
|
|
this.charts.stochastic = LightweightCharts.createChart(
|
|
document.getElementById('stochastic-chart'),
|
|
{ ...chartOptions, height: 150 }
|
|
);
|
|
|
|
this.series.stochK = this.charts.stochastic.addLineSeries({
|
|
color: '#2196f3',
|
|
lineWidth: 2,
|
|
title: '%K',
|
|
});
|
|
this.series.stochD = this.charts.stochastic.addLineSeries({
|
|
color: '#ff5722',
|
|
lineWidth: 2,
|
|
title: '%D',
|
|
});
|
|
|
|
// Stochastic Overbought/Oversold lines
|
|
this.series.stochOverbought = this.charts.stochastic.addLineSeries({
|
|
color: 'rgba(239, 83, 80, 0.5)',
|
|
lineWidth: 1,
|
|
lineStyle: 2,
|
|
});
|
|
this.series.stochOversold = this.charts.stochastic.addLineSeries({
|
|
color: 'rgba(38, 166, 154, 0.5)',
|
|
lineWidth: 1,
|
|
lineStyle: 2,
|
|
});
|
|
|
|
// Sync all charts
|
|
this.syncCharts();
|
|
}
|
|
|
|
syncCharts() {
|
|
const charts = [this.charts.main, this.charts.macd, this.charts.rsi, this.charts.stochastic];
|
|
let isSyncing = false;
|
|
|
|
charts.forEach((chart, index) => {
|
|
chart.timeScale().subscribeVisibleLogicalRangeChange((logicalRange) => {
|
|
if (isSyncing || !logicalRange) return;
|
|
isSyncing = true;
|
|
charts.forEach((otherChart, otherIndex) => {
|
|
if (index !== otherIndex) {
|
|
otherChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
}
|
|
});
|
|
isSyncing = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
setupEventListeners() {
|
|
document.getElementById('timeframe').addEventListener('change', async (e) => {
|
|
this.timeframe = e.target.value;
|
|
// Close WebSocket first to prevent data conflicts
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
// Clear all data
|
|
this.candleData = [];
|
|
// Clear chart series data to prevent "Cannot update oldest data" error
|
|
this.series.candle.setData([]);
|
|
this.series.volume.setData([]);
|
|
this.series.ma5.setData([]);
|
|
this.series.ma20.setData([]);
|
|
this.series.ma60.setData([]);
|
|
this.series.ma120.setData([]);
|
|
this.series.ma200.setData([]);
|
|
this.series.bbUpper.setData([]);
|
|
this.series.bbLower.setData([]);
|
|
this.series.macdLine.setData([]);
|
|
this.series.signalLine.setData([]);
|
|
this.series.histogram.setData([]);
|
|
this.series.rsi.setData([]);
|
|
this.series.rsiOverbought.setData([]);
|
|
this.series.rsiOversold.setData([]);
|
|
this.series.stochK.setData([]);
|
|
this.series.stochD.setData([]);
|
|
this.series.stochOverbought.setData([]);
|
|
this.series.stochOversold.setData([]);
|
|
|
|
await this.loadHistoricalData();
|
|
this.connectWebSocket();
|
|
});
|
|
|
|
// MA toggle handlers
|
|
const maToggles = {
|
|
'toggle-ma5': 'ma5',
|
|
'toggle-ma20': 'ma20',
|
|
'toggle-ma60': 'ma60',
|
|
'toggle-ma120': 'ma120',
|
|
'toggle-ma200': 'ma200',
|
|
};
|
|
|
|
Object.entries(maToggles).forEach(([toggleId, seriesName]) => {
|
|
document.getElementById(toggleId).addEventListener('change', (e) => {
|
|
this.series[seriesName].applyOptions({
|
|
visible: e.target.checked
|
|
});
|
|
});
|
|
});
|
|
|
|
// Bollinger Bands toggle
|
|
document.getElementById('toggle-bb').addEventListener('change', (e) => {
|
|
const visible = e.target.checked;
|
|
this.series.bbUpper.applyOptions({ visible });
|
|
this.series.bbLower.applyOptions({ visible });
|
|
});
|
|
|
|
// GC/DC toggle
|
|
document.getElementById('toggle-gcdc').addEventListener('change', (e) => {
|
|
this.showGCDC = e.target.checked;
|
|
this.updateMarkers();
|
|
});
|
|
|
|
// Resize handler
|
|
window.addEventListener('resize', () => {
|
|
Object.values(this.charts).forEach(chart => {
|
|
chart.applyOptions({ width: chart.options().width });
|
|
});
|
|
});
|
|
}
|
|
|
|
async loadHistoricalData() {
|
|
try {
|
|
const interval = this.timeframe;
|
|
const url = `https://api.binance.com/api/v3/klines?symbol=${this.symbol}&interval=${interval}&limit=1000`;
|
|
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
this.candleData = data.map(d => ({
|
|
time: Math.floor(d[0] / 1000),
|
|
open: parseFloat(d[1]),
|
|
high: parseFloat(d[2]),
|
|
low: parseFloat(d[3]),
|
|
close: parseFloat(d[4]),
|
|
volume: parseFloat(d[5]),
|
|
}));
|
|
|
|
this.updateAllCharts();
|
|
this.updatePriceDisplay();
|
|
} catch (error) {
|
|
console.error('Error loading historical data:', error);
|
|
}
|
|
}
|
|
|
|
updateAllCharts() {
|
|
if (this.candleData.length === 0) return;
|
|
|
|
// Update candlestick
|
|
this.series.candle.setData(this.candleData);
|
|
|
|
// Update volume
|
|
const volumeData = this.candleData.map(d => ({
|
|
time: d.time,
|
|
value: d.volume,
|
|
color: d.close >= d.open ? 'rgba(38, 166, 154, 0.5)' : 'rgba(239, 83, 80, 0.5)'
|
|
}));
|
|
this.series.volume.setData(volumeData);
|
|
|
|
// Calculate and update Moving Averages
|
|
const ma5 = TechnicalIndicators.SMA(this.candleData, 5);
|
|
const ma20 = TechnicalIndicators.SMA(this.candleData, 20);
|
|
const ma60 = TechnicalIndicators.SMA(this.candleData, 60);
|
|
const ma120 = TechnicalIndicators.SMA(this.candleData, 120);
|
|
const ma200 = TechnicalIndicators.SMA(this.candleData, 200);
|
|
|
|
this.series.ma5.setData(ma5.filter(d => d.value !== null));
|
|
this.series.ma20.setData(ma20.filter(d => d.value !== null));
|
|
this.series.ma60.setData(ma60.filter(d => d.value !== null));
|
|
this.series.ma120.setData(ma120.filter(d => d.value !== null));
|
|
this.series.ma200.setData(ma200.filter(d => d.value !== null));
|
|
|
|
// Calculate and update Bollinger Bands (Middle is same as MA20, so skip it)
|
|
const bb = TechnicalIndicators.BollingerBands(this.candleData, 20, 2);
|
|
this.series.bbUpper.setData(bb.upper.filter(d => d.value !== null));
|
|
this.series.bbLower.setData(bb.lower.filter(d => d.value !== null));
|
|
|
|
// Calculate and update MACD
|
|
const macd = TechnicalIndicators.MACD(this.candleData, 12, 26, 9);
|
|
// Keep null values as 0 to maintain time alignment
|
|
this.series.macdLine.setData(macd.macdLine.map(d => ({ time: d.time, value: d.value ?? 0 })));
|
|
this.series.signalLine.setData(macd.signalLine.map(d => ({ time: d.time, value: d.value ?? 0 })));
|
|
this.series.histogram.setData(macd.histogram.map(d => ({ time: d.time, value: d.value ?? 0, color: d.color })));
|
|
|
|
// Calculate and update RSI
|
|
const rsi = TechnicalIndicators.RSI(this.candleData, 14);
|
|
this.series.rsi.setData(rsi.filter(d => d.value !== null));
|
|
|
|
// RSI reference lines
|
|
const rsiLineData = this.candleData.map(d => ({ time: d.time }));
|
|
this.series.rsiOverbought.setData(rsiLineData.map(d => ({ ...d, value: 70 })));
|
|
this.series.rsiOversold.setData(rsiLineData.map(d => ({ ...d, value: 30 })));
|
|
|
|
// Calculate and update Stochastic
|
|
const stochastic = TechnicalIndicators.Stochastic(this.candleData, 14, 3, 3);
|
|
this.series.stochK.setData(stochastic.k.filter(d => d.value !== null));
|
|
this.series.stochD.setData(stochastic.d.filter(d => d.value !== null));
|
|
|
|
// Stochastic reference lines
|
|
this.series.stochOverbought.setData(rsiLineData.map(d => ({ ...d, value: 80 })));
|
|
this.series.stochOversold.setData(rsiLineData.map(d => ({ ...d, value: 20 })));
|
|
|
|
// Detect and display signals
|
|
this.detectSignals(ma5, ma20, ma60, rsi, macd, stochastic);
|
|
|
|
// Update analysis panel
|
|
this.updateAnalysis(ma5, ma20, ma60, bb, macd, rsi, stochastic);
|
|
|
|
// First fit content, then set visible range after a short delay
|
|
Object.values(this.charts).forEach(chart => {
|
|
chart.timeScale().fitContent();
|
|
});
|
|
|
|
// Show last 150 candles by default after rendering
|
|
setTimeout(() => this.setVisibleCandles(150), 100);
|
|
}
|
|
|
|
setVisibleCandles(count) {
|
|
if (this.candleData.length > 0) {
|
|
const lastIndex = this.candleData.length - 1;
|
|
const fromIndex = Math.max(0, lastIndex - count);
|
|
|
|
Object.values(this.charts).forEach(chart => {
|
|
chart.timeScale().setVisibleLogicalRange({
|
|
from: fromIndex,
|
|
to: lastIndex + 10,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
detectSignals(ma5, ma20, ma60, rsi, macd, stochastic) {
|
|
const allSignals = [];
|
|
const markers = [];
|
|
|
|
// MA Cross signals (5-20, 20-60)
|
|
const ma5_20Cross = TechnicalIndicators.detectCrosses(ma5, ma20);
|
|
const ma20_60Cross = TechnicalIndicators.detectCrosses(ma20, ma60);
|
|
|
|
ma5_20Cross.forEach(signal => {
|
|
allSignals.push({
|
|
...signal,
|
|
description: signal.type === 'golden' ? 'MA5-20 Golden Cross' : 'MA5-20 Dead Cross'
|
|
});
|
|
markers.push({
|
|
time: signal.time,
|
|
position: signal.type === 'golden' ? 'belowBar' : 'aboveBar',
|
|
color: signal.type === 'golden' ? '#26a69a' : '#ef5350',
|
|
shape: signal.type === 'golden' ? 'arrowUp' : 'arrowDown',
|
|
text: signal.type === 'golden' ? 'GC' : 'DC'
|
|
});
|
|
});
|
|
|
|
ma20_60Cross.forEach(signal => {
|
|
allSignals.push({
|
|
...signal,
|
|
description: signal.type === 'golden' ? 'MA20-60 Golden Cross' : 'MA20-60 Dead Cross'
|
|
});
|
|
markers.push({
|
|
time: signal.time,
|
|
position: signal.type === 'golden' ? 'belowBar' : 'aboveBar',
|
|
color: signal.type === 'golden' ? '#26a69a' : '#ef5350',
|
|
shape: signal.type === 'golden' ? 'arrowUp' : 'arrowDown',
|
|
text: signal.type === 'golden' ? 'GC20' : 'DC20'
|
|
});
|
|
});
|
|
|
|
// RSI signals
|
|
const rsiSignals = TechnicalIndicators.detectRSISignals(rsi);
|
|
rsiSignals.forEach(signal => {
|
|
allSignals.push(signal);
|
|
});
|
|
|
|
// MACD signals
|
|
const macdSignals = TechnicalIndicators.detectMACDSignals(macd);
|
|
macdSignals.forEach(signal => {
|
|
allSignals.push(signal);
|
|
});
|
|
|
|
// Stochastic signals
|
|
const stochSignals = TechnicalIndicators.detectStochasticSignals(stochastic);
|
|
stochSignals.forEach(signal => {
|
|
allSignals.push(signal);
|
|
});
|
|
|
|
// Store markers and update display
|
|
this.gcDcMarkers = markers.sort((a, b) => a.time - b.time);
|
|
this.updateMarkers();
|
|
|
|
// Update signal list (last 20 signals)
|
|
this.signals = allSignals.sort((a, b) => b.time - a.time).slice(0, 20);
|
|
this.updateSignalList();
|
|
}
|
|
|
|
updateMarkers() {
|
|
if (this.showGCDC) {
|
|
this.series.candle.setMarkers(this.gcDcMarkers);
|
|
} else {
|
|
this.series.candle.setMarkers([]);
|
|
}
|
|
}
|
|
|
|
updateSignalList() {
|
|
// Signal list panel was removed, so this function is now a no-op
|
|
// Keeping for potential future use
|
|
}
|
|
|
|
updatePriceDisplay() {
|
|
if (this.candleData.length === 0) return;
|
|
|
|
const currentCandle = this.candleData[this.candleData.length - 1];
|
|
|
|
const priceEl = document.getElementById('current-price');
|
|
const changeEl = document.getElementById('price-change');
|
|
|
|
priceEl.textContent = `$${currentCandle.close.toLocaleString('en-US', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2
|
|
})}`;
|
|
|
|
// KST 09:00 기준 변동률 계산
|
|
if (this.dailyOpenPrice) {
|
|
const change = currentCandle.close - this.dailyOpenPrice;
|
|
const changePercent = (change / this.dailyOpenPrice) * 100;
|
|
const isUp = change >= 0;
|
|
|
|
changeEl.textContent = `${isUp ? '+' : ''}${change.toFixed(2)} (${isUp ? '+' : ''}${changePercent.toFixed(2)}%)`;
|
|
changeEl.className = isUp ? 'up' : 'down';
|
|
changeEl.title = 'KST 09:00 기준 변동률';
|
|
}
|
|
}
|
|
|
|
connectWebSocket() {
|
|
const wsUrl = `wss://stream.binance.com:9443/ws/${this.symbol.toLowerCase()}@kline_${this.timeframe}`;
|
|
|
|
this.ws = new WebSocket(wsUrl);
|
|
|
|
this.ws.onopen = () => {
|
|
console.log('WebSocket connected');
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
if (data.k) {
|
|
this.handleKlineData(data.k);
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
console.log('WebSocket disconnected.');
|
|
// Only auto-reconnect if ws is still set (not manually closed)
|
|
if (this.ws) {
|
|
console.log('Reconnecting in 5 seconds...');
|
|
setTimeout(() => this.connectWebSocket(), 5000);
|
|
}
|
|
};
|
|
}
|
|
|
|
handleKlineData(kline) {
|
|
// Validate that candle data exists (prevents errors during timeframe switch)
|
|
if (this.candleData.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const candle = {
|
|
time: Math.floor(kline.t / 1000),
|
|
open: parseFloat(kline.o),
|
|
high: parseFloat(kline.h),
|
|
low: parseFloat(kline.l),
|
|
close: parseFloat(kline.c),
|
|
volume: parseFloat(kline.v),
|
|
};
|
|
|
|
// Check if this is an update to existing candle or new candle
|
|
const lastCandle = this.candleData[this.candleData.length - 1];
|
|
|
|
// Validate candle time - reject if older than our last candle (stale data from previous timeframe)
|
|
if (lastCandle && candle.time < lastCandle.time) {
|
|
console.log('Ignoring stale candle data:', candle.time, 'vs', lastCandle.time);
|
|
return;
|
|
}
|
|
|
|
const isNewCandle = !lastCandle || lastCandle.time !== candle.time;
|
|
|
|
if (lastCandle && lastCandle.time === candle.time) {
|
|
// Update existing candle
|
|
this.candleData[this.candleData.length - 1] = candle;
|
|
} else {
|
|
// New candle started - update analysis with previous candle data before adding new one
|
|
this.candleData.push(candle);
|
|
// Keep only last 500 candles
|
|
if (this.candleData.length > 500) {
|
|
this.candleData.shift();
|
|
}
|
|
}
|
|
|
|
// Update chart with new data
|
|
try {
|
|
this.series.candle.update(candle);
|
|
|
|
// Update volume
|
|
this.series.volume.update({
|
|
time: candle.time,
|
|
value: candle.volume,
|
|
color: candle.close >= candle.open ? 'rgba(38, 166, 154, 0.5)' : 'rgba(239, 83, 80, 0.5)'
|
|
});
|
|
} catch (error) {
|
|
console.warn('Chart update error (may occur during timeframe switch):', error.message);
|
|
return;
|
|
}
|
|
|
|
// Update indicators (chart lines) in real-time
|
|
// Update analysis panel when a new candle starts (previous candle is complete)
|
|
this.updateIndicatorsRealtime(isNewCandle);
|
|
this.updatePriceDisplay();
|
|
}
|
|
|
|
updateIndicatorsRealtime(updateAnalysisPanel = false) {
|
|
// Recalculate all indicators with updated data
|
|
// For performance, we could optimize to only update the last few values
|
|
|
|
const ma5 = TechnicalIndicators.SMA(this.candleData, 5);
|
|
const ma20 = TechnicalIndicators.SMA(this.candleData, 20);
|
|
const ma60 = TechnicalIndicators.SMA(this.candleData, 60);
|
|
const ma120 = TechnicalIndicators.SMA(this.candleData, 120);
|
|
const ma200 = TechnicalIndicators.SMA(this.candleData, 200);
|
|
|
|
// Update only the last point for real-time
|
|
const lastIdx = this.candleData.length - 1;
|
|
|
|
if (ma5[lastIdx]?.value) this.series.ma5.update(ma5[lastIdx]);
|
|
if (ma20[lastIdx]?.value) this.series.ma20.update(ma20[lastIdx]);
|
|
if (ma60[lastIdx]?.value) this.series.ma60.update(ma60[lastIdx]);
|
|
if (ma120[lastIdx]?.value) this.series.ma120.update(ma120[lastIdx]);
|
|
if (ma200[lastIdx]?.value) this.series.ma200.update(ma200[lastIdx]);
|
|
|
|
// Bollinger Bands (Middle is same as MA20, so skip it)
|
|
const bb = TechnicalIndicators.BollingerBands(this.candleData, 20, 2);
|
|
if (bb.upper[lastIdx]?.value) this.series.bbUpper.update(bb.upper[lastIdx]);
|
|
if (bb.lower[lastIdx]?.value) this.series.bbLower.update(bb.lower[lastIdx]);
|
|
|
|
// MACD
|
|
const macd = TechnicalIndicators.MACD(this.candleData, 12, 26, 9);
|
|
if (macd.macdLine[lastIdx]?.value) this.series.macdLine.update(macd.macdLine[lastIdx]);
|
|
if (macd.signalLine[lastIdx]?.value) this.series.signalLine.update(macd.signalLine[lastIdx]);
|
|
if (macd.histogram[lastIdx]) this.series.histogram.update(macd.histogram[lastIdx]);
|
|
|
|
// RSI
|
|
const rsi = TechnicalIndicators.RSI(this.candleData, 14);
|
|
if (rsi[lastIdx]?.value) this.series.rsi.update(rsi[lastIdx]);
|
|
|
|
// Stochastic
|
|
const stochastic = TechnicalIndicators.Stochastic(this.candleData, 14, 3, 3);
|
|
if (stochastic.k[lastIdx]?.value) this.series.stochK.update(stochastic.k[lastIdx]);
|
|
if (stochastic.d[lastIdx]?.value) this.series.stochD.update(stochastic.d[lastIdx]);
|
|
|
|
// Update analysis panel only when candle is closed (completed)
|
|
if (updateAnalysisPanel) {
|
|
this.updateAnalysis(ma5, ma20, ma60, bb, macd, rsi, stochastic);
|
|
}
|
|
}
|
|
|
|
updateAnalysis(ma5, ma20, ma60, bb, macd, rsi, stochastic) {
|
|
// Use the last candle for analysis
|
|
const lastIdx = this.candleData.length - 1;
|
|
if (lastIdx < 0) return;
|
|
|
|
const lastCandle = this.candleData[lastIdx];
|
|
const analyzedPrice = lastCandle.close;
|
|
let totalScore = 0;
|
|
let analysisCount = 0;
|
|
|
|
// Update time to show the analyzed candle's time
|
|
const candleTime = new Date(lastCandle.time * 1000);
|
|
const timeStr = candleTime.toLocaleString('ko-KR', {
|
|
timeZone: 'Asia/Seoul',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
document.getElementById('analysis-time').textContent = timeStr;
|
|
|
|
// 1. MA Trend Analysis
|
|
const ma5Val = ma5[lastIdx]?.value;
|
|
const ma20Val = ma20[lastIdx]?.value;
|
|
const ma60Val = ma60[lastIdx]?.value;
|
|
let maSignal = 'neutral';
|
|
let maDetail = '';
|
|
let maScore = 0;
|
|
|
|
if (ma5Val && ma20Val && ma60Val) {
|
|
if (analyzedPrice > ma5Val && ma5Val > ma20Val && ma20Val > ma60Val) {
|
|
maSignal = 'buy';
|
|
maDetail = 'Strong uptrend';
|
|
maScore = 2;
|
|
} else if (analyzedPrice > ma5Val && ma5Val > ma20Val) {
|
|
maSignal = 'buy';
|
|
maDetail = 'Uptrend';
|
|
maScore = 1;
|
|
} else if (analyzedPrice < ma5Val && ma5Val < ma20Val && ma20Val < ma60Val) {
|
|
maSignal = 'sell';
|
|
maDetail = 'Strong downtrend';
|
|
maScore = -2;
|
|
} else if (analyzedPrice < ma5Val && ma5Val < ma20Val) {
|
|
maSignal = 'sell';
|
|
maDetail = 'Downtrend';
|
|
maScore = -1;
|
|
} else {
|
|
maDetail = 'Consolidating';
|
|
}
|
|
totalScore += maScore;
|
|
analysisCount++;
|
|
}
|
|
this.updateAnalysisItem('analysis-ma', maSignal, maSignal === 'buy' ? 'BUY' : maSignal === 'sell' ? 'SELL' : 'HOLD', maDetail);
|
|
|
|
// 2. Bollinger Bands Analysis
|
|
const bbUpper = bb.upper[lastIdx]?.value;
|
|
const bbLower = bb.lower[lastIdx]?.value;
|
|
let bbSignal = 'neutral';
|
|
let bbDetail = '';
|
|
let bbScore = 0;
|
|
|
|
if (bbUpper && bbLower) {
|
|
const bbMiddle = ma20Val;
|
|
const bbWidth = ((bbUpper - bbLower) / bbMiddle * 100).toFixed(1);
|
|
|
|
if (analyzedPrice >= bbUpper) {
|
|
bbSignal = 'sell';
|
|
bbDetail = `Overbought (${bbWidth}%)`;
|
|
bbScore = -1;
|
|
} else if (analyzedPrice <= bbLower) {
|
|
bbSignal = 'buy';
|
|
bbDetail = `Oversold (${bbWidth}%)`;
|
|
bbScore = 1;
|
|
} else if (analyzedPrice > bbMiddle) {
|
|
bbDetail = `Upper zone (${bbWidth}%)`;
|
|
} else {
|
|
bbDetail = `Lower zone (${bbWidth}%)`;
|
|
}
|
|
totalScore += bbScore;
|
|
analysisCount++;
|
|
}
|
|
this.updateAnalysisItem('analysis-bb', bbSignal, bbSignal === 'buy' ? 'BUY' : bbSignal === 'sell' ? 'SELL' : 'HOLD', bbDetail);
|
|
|
|
// 3. MACD Analysis
|
|
const macdVal = macd.macdLine[lastIdx]?.value;
|
|
const signalVal = macd.signalLine[lastIdx]?.value;
|
|
const histVal = macd.histogram[lastIdx]?.value;
|
|
let macdSignal = 'neutral';
|
|
let macdDetail = '';
|
|
let macdScore = 0;
|
|
|
|
if (macdVal !== null && signalVal !== null) {
|
|
const prevHistVal = macd.histogram[lastIdx - 1]?.value ?? 0;
|
|
|
|
if (macdVal > signalVal && histVal > 0) {
|
|
macdSignal = 'buy';
|
|
macdDetail = histVal > prevHistVal ? 'Bullish momentum+' : 'Bullish';
|
|
macdScore = histVal > prevHistVal ? 2 : 1;
|
|
} else if (macdVal < signalVal && histVal < 0) {
|
|
macdSignal = 'sell';
|
|
macdDetail = histVal < prevHistVal ? 'Bearish momentum+' : 'Bearish';
|
|
macdScore = histVal < prevHistVal ? -2 : -1;
|
|
} else {
|
|
macdDetail = 'Crossing';
|
|
}
|
|
totalScore += macdScore;
|
|
analysisCount++;
|
|
}
|
|
this.updateAnalysisItem('analysis-macd', macdSignal, macdSignal === 'buy' ? 'BUY' : macdSignal === 'sell' ? 'SELL' : 'HOLD', macdDetail);
|
|
|
|
// 4. RSI Analysis
|
|
const rsiVal = rsi[lastIdx]?.value;
|
|
let rsiSignal = 'neutral';
|
|
let rsiDetail = '';
|
|
let rsiScore = 0;
|
|
|
|
if (rsiVal !== null) {
|
|
if (rsiVal >= 70) {
|
|
rsiSignal = 'sell';
|
|
rsiDetail = `Overbought (${rsiVal.toFixed(1)})`;
|
|
rsiScore = -1;
|
|
} else if (rsiVal <= 30) {
|
|
rsiSignal = 'buy';
|
|
rsiDetail = `Oversold (${rsiVal.toFixed(1)})`;
|
|
rsiScore = 1;
|
|
} else if (rsiVal >= 50) {
|
|
rsiDetail = `Bullish (${rsiVal.toFixed(1)})`;
|
|
rsiScore = 0.5;
|
|
} else {
|
|
rsiDetail = `Bearish (${rsiVal.toFixed(1)})`;
|
|
rsiScore = -0.5;
|
|
}
|
|
totalScore += rsiScore;
|
|
analysisCount++;
|
|
}
|
|
this.updateAnalysisItem('analysis-rsi', rsiSignal, rsiSignal === 'buy' ? 'BUY' : rsiSignal === 'sell' ? 'SELL' : 'HOLD', rsiDetail);
|
|
|
|
// 5. Stochastic Analysis
|
|
const stochKVal = stochastic.k[lastIdx]?.value;
|
|
const stochDVal = stochastic.d[lastIdx]?.value;
|
|
let stochSignal = 'neutral';
|
|
let stochDetail = '';
|
|
let stochScore = 0;
|
|
|
|
if (stochKVal !== null && stochDVal !== null) {
|
|
if (stochKVal >= 80) {
|
|
stochSignal = 'sell';
|
|
stochDetail = `Overbought (${stochKVal.toFixed(1)})`;
|
|
stochScore = -1;
|
|
} else if (stochKVal <= 20) {
|
|
stochSignal = 'buy';
|
|
stochDetail = `Oversold (${stochKVal.toFixed(1)})`;
|
|
stochScore = 1;
|
|
} else if (stochKVal > stochDVal) {
|
|
stochDetail = `Bullish (${stochKVal.toFixed(1)})`;
|
|
stochScore = 0.5;
|
|
} else {
|
|
stochDetail = `Bearish (${stochKVal.toFixed(1)})`;
|
|
stochScore = -0.5;
|
|
}
|
|
totalScore += stochScore;
|
|
analysisCount++;
|
|
}
|
|
this.updateAnalysisItem('analysis-stoch', stochSignal, stochSignal === 'buy' ? 'BUY' : stochSignal === 'sell' ? 'SELL' : 'HOLD', stochDetail);
|
|
|
|
// Calculate overall signal
|
|
const avgScore = analysisCount > 0 ? totalScore / analysisCount : 0;
|
|
let overallSignal = 'NEUTRAL';
|
|
let overallClass = 'neutral';
|
|
|
|
if (avgScore >= 1) {
|
|
overallSignal = 'STRONG BUY';
|
|
overallClass = 'buy';
|
|
} else if (avgScore >= 0.3) {
|
|
overallSignal = 'BUY';
|
|
overallClass = 'buy';
|
|
} else if (avgScore <= -1) {
|
|
overallSignal = 'STRONG SELL';
|
|
overallClass = 'sell';
|
|
} else if (avgScore <= -0.3) {
|
|
overallSignal = 'SELL';
|
|
overallClass = 'sell';
|
|
}
|
|
|
|
const overallEl = document.querySelector('#overall-signal .signal-value');
|
|
overallEl.textContent = overallSignal;
|
|
overallEl.className = `signal-value ${overallClass}`;
|
|
|
|
// Update score marker (normalize to 0-100 scale, where 50 is neutral)
|
|
const normalizedScore = Math.max(0, Math.min(100, 50 + avgScore * 25));
|
|
document.getElementById('score-marker').style.left = `${normalizedScore}%`;
|
|
|
|
// Update summary text
|
|
this.updateSummaryText(avgScore, maSignal, bbSignal, macdSignal, rsiSignal, stochSignal);
|
|
|
|
// Update Korean analysis
|
|
this.updateKoreanAnalysis(avgScore, maDetail, bbDetail, macdDetail, rsiDetail, stochDetail, rsiVal, stochKVal);
|
|
}
|
|
|
|
updateAnalysisItem(elementId, signal, value, detail) {
|
|
const item = document.getElementById(elementId);
|
|
const valueEl = item.querySelector('.item-value');
|
|
const detailEl = item.querySelector('.item-detail');
|
|
|
|
valueEl.textContent = value;
|
|
valueEl.className = `item-value ${signal}`;
|
|
detailEl.textContent = detail;
|
|
}
|
|
|
|
updateSummaryText(avgScore, ma, bb, macd, rsi, stoch) {
|
|
let summary = '';
|
|
const buySignals = [ma, bb, macd, rsi, stoch].filter(s => s === 'buy').length;
|
|
const sellSignals = [ma, bb, macd, rsi, stoch].filter(s => s === 'sell').length;
|
|
|
|
if (avgScore >= 1) {
|
|
summary = `Strong bullish momentum. ${buySignals}/5 indicators show buy signals. Consider long positions with proper risk management.`;
|
|
} else if (avgScore >= 0.3) {
|
|
summary = `Moderate bullish bias. ${buySignals}/5 buy signals detected. Market shows upward tendency.`;
|
|
} else if (avgScore <= -1) {
|
|
summary = `Strong bearish momentum. ${sellSignals}/5 indicators show sell signals. Consider short positions or wait for reversal.`;
|
|
} else if (avgScore <= -0.3) {
|
|
summary = `Moderate bearish bias. ${sellSignals}/5 sell signals detected. Market shows downward tendency.`;
|
|
} else {
|
|
summary = `Market is consolidating. Mixed signals from indicators. Wait for clearer direction before entering positions.`;
|
|
}
|
|
|
|
document.getElementById('analysis-summary-text').textContent = summary;
|
|
}
|
|
|
|
updateKoreanAnalysis(avgScore, maDetail, bbDetail, macdDetail, rsiDetail, stochDetail, rsiVal, stochKVal) {
|
|
const parts = [];
|
|
|
|
// 전체 시장 상황
|
|
if (avgScore >= 1) {
|
|
parts.push('현재 시장은 강한 상승 추세입니다.');
|
|
} else if (avgScore >= 0.3) {
|
|
parts.push('현재 시장은 상승 추세입니다.');
|
|
} else if (avgScore <= -1) {
|
|
parts.push('현재 시장은 강한 하락 추세입니다.');
|
|
} else if (avgScore <= -0.3) {
|
|
parts.push('현재 시장은 하락 추세입니다.');
|
|
} else {
|
|
parts.push('현재 시장은 횡보 구간입니다.');
|
|
}
|
|
|
|
// 이동평균선 분석
|
|
if (maDetail.includes('Strong uptrend')) {
|
|
parts.push('이동평균선이 정배열로 강한 매수세를 보이고 있습니다.');
|
|
} else if (maDetail.includes('Uptrend')) {
|
|
parts.push('단기 이동평균선이 상승 추세를 나타내고 있습니다.');
|
|
} else if (maDetail.includes('Strong downtrend')) {
|
|
parts.push('이동평균선이 역배열로 강한 매도세를 보이고 있습니다.');
|
|
} else if (maDetail.includes('Downtrend')) {
|
|
parts.push('단기 이동평균선이 하락 추세를 나타내고 있습니다.');
|
|
} else if (maDetail.includes('Consolidating')) {
|
|
parts.push('이동평균선이 수렴하며 방향성을 찾는 중입니다.');
|
|
}
|
|
|
|
// 볼린저 밴드 분석
|
|
if (bbDetail.includes('Overbought')) {
|
|
parts.push('가격이 볼린저 밴드 상단에 위치하여 과매수 구간입니다.');
|
|
} else if (bbDetail.includes('Oversold')) {
|
|
parts.push('가격이 볼린저 밴드 하단에 위치하여 과매도 구간입니다.');
|
|
}
|
|
|
|
// RSI 분석
|
|
if (rsiVal !== null) {
|
|
if (rsiVal >= 70) {
|
|
parts.push(`RSI가 ${rsiVal.toFixed(0)}으로 과매수 상태이며, 조정 가능성이 있습니다.`);
|
|
} else if (rsiVal <= 30) {
|
|
parts.push(`RSI가 ${rsiVal.toFixed(0)}으로 과매도 상태이며, 반등 가능성이 있습니다.`);
|
|
} else if (rsiVal >= 60) {
|
|
parts.push(`RSI가 ${rsiVal.toFixed(0)}으로 매수세가 우세합니다.`);
|
|
} else if (rsiVal <= 40) {
|
|
parts.push(`RSI가 ${rsiVal.toFixed(0)}으로 매도세가 우세합니다.`);
|
|
}
|
|
}
|
|
|
|
// MACD 분석
|
|
if (macdDetail.includes('Bullish momentum+')) {
|
|
parts.push('MACD 히스토그램이 증가하며 상승 모멘텀이 강화되고 있습니다.');
|
|
} else if (macdDetail.includes('Bullish')) {
|
|
parts.push('MACD가 시그널선 위에서 상승 신호를 유지하고 있습니다.');
|
|
} else if (macdDetail.includes('Bearish momentum+')) {
|
|
parts.push('MACD 히스토그램이 감소하며 하락 모멘텀이 강화되고 있습니다.');
|
|
} else if (macdDetail.includes('Bearish')) {
|
|
parts.push('MACD가 시그널선 아래에서 하락 신호를 유지하고 있습니다.');
|
|
} else if (macdDetail.includes('Crossing')) {
|
|
parts.push('MACD와 시그널선이 교차 중으로 추세 전환 가능성이 있습니다.');
|
|
}
|
|
|
|
// 스토캐스틱 분석
|
|
if (stochKVal !== null) {
|
|
if (stochKVal >= 80) {
|
|
parts.push('스토캐스틱이 과매수 구간에 진입했습니다.');
|
|
} else if (stochKVal <= 20) {
|
|
parts.push('스토캐스틱이 과매도 구간에 진입했습니다.');
|
|
}
|
|
}
|
|
|
|
// 투자 제안
|
|
if (avgScore >= 1) {
|
|
parts.push('매수 포지션 진입을 고려해볼 수 있으나, 리스크 관리에 유의하세요.');
|
|
} else if (avgScore >= 0.3) {
|
|
parts.push('상승 가능성이 높으나 추가 확인 후 진입을 권장합니다.');
|
|
} else if (avgScore <= -1) {
|
|
parts.push('매도 또는 관망을 권장하며, 반등 신호 확인이 필요합니다.');
|
|
} else if (avgScore <= -0.3) {
|
|
parts.push('하락 가능성이 있으니 신규 매수는 신중하게 접근하세요.');
|
|
} else {
|
|
parts.push('명확한 방향성이 나올 때까지 관망을 권장합니다.');
|
|
}
|
|
|
|
document.getElementById('korean-analysis-text').textContent = parts.join(' ');
|
|
}
|
|
|
|
reconnectWebSocket() {
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
}
|
|
this.connectWebSocket();
|
|
}
|
|
}
|
|
|
|
// Initialize the application
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.tradingView = new TradingView();
|
|
});
|