Files
bini-trading-view/app.js
kihong.kim 022fddec9c
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
Add economic calendar feature with n8n integration
- Add economic calendar tab with monthly view
- Display today's events in header
- Add weekly summary section
- Integrate with Forex Factory via n8n webhook
- Add Header Auth API authentication
- Add KST timezone conversion
- Add country filter (US, JP, CN)
- Add importance-based event styling
- Add more events modal for days with many events
- Update calendar grid to show up to 4 events per day
- Add n8n workflow configuration files
2025-12-31 15:24:30 +09:00

1685 lines
65 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();
}
}
// Economic Calendar Class
class EconomicCalendar {
constructor() {
this.events = [];
this.filteredEvents = [];
this.selectedCountry = 'all';
this.selectedPeriod = 'week';
this.currentMonth = new Date();
// 데이터 소스 설정:
// - 로컬 JSON 파일: 'data/economic-calendar.json'
// - n8n webhook: 'https://n8n.binibini.dedyn.io/webhook/economic-calendar'
this.dataUrl = 'https://n8n.binibini.dedyn.io/webhook/economic-calendar';
// API 인증 키 (n8n Header Auth와 동일한 값 사용)
// 키 생성: openssl rand -hex 32
this.apiKey = 'd46c4eb3b8ce1864c4994c45db11f80f1732fecd3caebd56518f5f6ce2be205e';
this.init();
}
async init() {
this.setupEventListeners();
await this.loadCalendarData();
this.renderHeaderEvents();
this.renderWeeklySummary();
this.renderCalendarList();
}
setupEventListeners() {
// Tab navigation
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.target.dataset.tab;
this.switchTab(tab);
});
});
// Country filter buttons
document.querySelectorAll('.country-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.country-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
this.selectedCountry = e.target.dataset.country;
this.renderCalendarList();
});
});
// Period filter
document.getElementById('calendar-period')?.addEventListener('change', (e) => {
this.selectedPeriod = e.target.value;
this.renderCalendarList();
});
// "More" button to switch to calendar tab
document.getElementById('show-calendar-btn')?.addEventListener('click', () => {
this.switchTab('calendar');
});
}
switchTab(tab) {
// Update tab buttons
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
// Update tab content
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('active', content.id === `${tab}-tab`);
});
// Show/hide timeframe selector
const timeframeSelect = document.getElementById('timeframe');
if (timeframeSelect) {
timeframeSelect.style.display = tab === 'chart' ? 'block' : 'none';
}
}
async loadCalendarData() {
try {
const headers = {};
// API 키가 설정된 경우 인증 헤더 추가
if (this.apiKey && this.apiKey !== 'YOUR_API_KEY_HERE') {
headers['X-API-Key'] = this.apiKey;
}
const response = await fetch(this.dataUrl, { headers });
if (response.ok) {
const data = await response.json();
// n8n webhook returns {data: [...]}, local JSON returns [...]
this.events = Array.isArray(data) ? data : (data.data || []);
} else if (response.status === 401 || response.status === 403) {
console.warn('API 인증 실패: API 키를 확인하세요');
this.events = this.getSampleData();
} else {
// Use sample data if file doesn't exist
this.events = this.getSampleData();
}
} catch (error) {
console.log('Using sample calendar data');
this.events = this.getSampleData();
}
}
getSampleData() {
const today = new Date();
// 로컬 시간 기준 YYYY-MM-DD 형식 반환 (UTC 변환으로 인한 날짜 밀림 방지)
const getDateStr = (daysOffset) => {
const d = new Date(today);
d.setDate(d.getDate() + daysOffset);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
return [
{
date: getDateStr(0),
time: '09:00',
country: 'CN',
name: '제조업 PMI',
importance: 3,
forecast: '50.2',
previous: '50.3',
actual: null
},
{
date: getDateStr(0),
time: '23:00',
country: 'US',
name: 'CB 소비자신뢰지수',
importance: 3,
forecast: '112.0',
previous: '111.7',
actual: null
},
{
date: getDateStr(1),
time: '08:30',
country: 'JP',
name: '실업률',
importance: 2,
forecast: '2.5%',
previous: '2.5%',
actual: null
},
{
date: getDateStr(1),
time: '10:00',
country: 'KR',
name: '산업생산 (MoM)',
importance: 2,
forecast: '0.3%',
previous: '-0.3%',
actual: null
},
{
date: getDateStr(2),
time: '22:00',
country: 'US',
name: 'ISM 제조업 PMI',
importance: 3,
forecast: '48.0',
previous: '46.7',
actual: null
},
{
date: getDateStr(3),
time: '04:00',
country: 'US',
name: 'FOMC 회의록',
importance: 3,
forecast: '-',
previous: '-',
actual: null
},
{
date: getDateStr(4),
time: '09:30',
country: 'CN',
name: 'Caixin 서비스업 PMI',
importance: 2,
forecast: '51.5',
previous: '51.5',
actual: null
},
{
date: getDateStr(4),
time: '22:30',
country: 'US',
name: '비농업 고용지수 (NFP)',
importance: 3,
forecast: '180K',
previous: '227K',
actual: null
},
{
date: getDateStr(5),
time: '08:00',
country: 'KR',
name: 'CPI (YoY)',
importance: 3,
forecast: '1.8%',
previous: '1.5%',
actual: null
},
{
date: getDateStr(6),
time: '10:30',
country: 'JP',
name: '경상수지',
importance: 2,
forecast: '2.5T',
previous: '2.3T',
actual: null
}
];
}
getCountryFlag(countryCode) {
const flags = {
'US': '🇺🇸',
'KR': '🇰🇷',
'CN': '🇨🇳',
'JP': '🇯🇵'
};
return flags[countryCode] || '🌐';
}
getFilteredEvents() {
let filtered = [...this.events];
// Filter by country
if (this.selectedCountry !== 'all') {
filtered = filtered.filter(e => e.country === this.selectedCountry);
}
// Filter by period
const today = new Date();
today.setHours(0, 0, 0, 0);
if (this.selectedPeriod === 'week') {
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + 7);
filtered = filtered.filter(e => {
const eventDate = new Date(e.date);
return eventDate >= today && eventDate < weekEnd;
});
} else if (this.selectedPeriod === 'month') {
const monthEnd = new Date(today);
monthEnd.setMonth(monthEnd.getMonth() + 1);
filtered = filtered.filter(e => {
const eventDate = new Date(e.date);
return eventDate >= today && eventDate < monthEnd;
});
}
// Sort by date and time
filtered.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0) return dateCompare;
return a.time.localeCompare(b.time);
});
return filtered;
}
renderHeaderEvents() {
const container = document.getElementById('header-events');
if (!container) return;
// Get today's date string (로컬 시간 기준)
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Filter today's events (importance === 3 only)
const todayEvents = this.events
.filter(e => e.date === todayStr && e.importance === 3)
.sort((a, b) => a.time.localeCompare(b.time))
.slice(0, 4); // Maximum 4 events in header
if (todayEvents.length === 0) {
container.innerHTML = '<span class="header-events-empty">오늘 주요 일정 없음</span>';
return;
}
container.innerHTML = todayEvents.map(event => {
const stars = '★'.repeat(event.importance);
return `
<div class="header-event-item importance-${event.importance}" title="${event.name} - ${event.forecast || '-'}">
<span class="header-event-time">${event.time}</span>
<span class="header-event-country">${this.getCountryFlag(event.country)}</span>
<span class="header-event-name">${event.name}</span>
<span class="header-event-stars">${stars}</span>
</div>
`;
}).join('');
}
renderWeeklySummary() {
const container = document.getElementById('weekly-events');
if (!container) return;
const weekEvents = this.events
.filter(e => {
const eventDate = new Date(e.date);
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + 7);
return eventDate >= today && eventDate < weekEnd && e.importance >= 2;
})
.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date);
if (dateCompare !== 0) return dateCompare;
return a.time.localeCompare(b.time);
})
.slice(0, 8);
if (weekEvents.length === 0) {
container.innerHTML = '<span class="loading-text">이번주 주요 일정이 없습니다</span>';
return;
}
container.innerHTML = weekEvents.map(event => {
const eventDate = new Date(event.date);
const dateStr = `${eventDate.getMonth() + 1}/${eventDate.getDate()}`;
const stars = '★'.repeat(event.importance);
return `
<div class="weekly-event-item">
<span class="weekly-event-date">${dateStr}</span>
<span class="weekly-event-country">${this.getCountryFlag(event.country)}</span>
<span class="weekly-event-name">${event.name}</span>
<span class="weekly-event-importance">${stars}</span>
</div>
`;
}).join('');
}
renderCalendarList() {
const container = document.getElementById('calendar-list');
if (!container) return;
// 월간 선택 시 캘린더 그리드로 표시
if (this.selectedPeriod === 'month') {
this.renderMonthlyCalendar(container);
return;
}
// 주간 선택 시 리스트로 표시
const events = this.getFilteredEvents();
if (events.length === 0) {
container.innerHTML = '<div class="loading-text">해당 기간에 일정이 없습니다</div>';
return;
}
// Group events by date
const grouped = events.reduce((acc, event) => {
if (!acc[event.date]) {
acc[event.date] = [];
}
acc[event.date].push(event);
return acc;
}, {});
const html = Object.entries(grouped).map(([date, dayEvents]) => {
const eventDate = new Date(date);
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dateStr = `${eventDate.getMonth() + 1}${eventDate.getDate()}일 (${dayNames[eventDate.getDay()]})`;
const eventsHtml = dayEvents.map(event => {
const stars = [1, 2, 3].map(i =>
`<span class="importance-star ${i <= event.importance ? 'active' : ''}">★</span>`
).join('');
return `
<div class="calendar-event">
<span class="event-time">${event.time}</span>
<span class="event-country">${this.getCountryFlag(event.country)}</span>
<div class="event-info">
<div class="event-name">${event.name}</div>
</div>
<div class="event-importance">${stars}</div>
<div class="event-values">
<div class="event-value">
<div class="event-value-label">예상</div>
<div class="event-value-number">${event.forecast || '-'}</div>
</div>
<div class="event-value">
<div class="event-value-label">이전</div>
<div class="event-value-number">${event.previous || '-'}</div>
</div>
<div class="event-value">
<div class="event-value-label">실제</div>
<div class="event-value-number ${event.actual ? (parseFloat(event.actual) > parseFloat(event.forecast) ? 'positive' : 'negative') : ''}">${event.actual || '-'}</div>
</div>
</div>
</div>
`;
}).join('');
return `
<div class="calendar-date-group">
<div class="calendar-date-header">${dateStr}</div>
${eventsHtml}
</div>
`;
}).join('');
container.innerHTML = html;
}
renderMonthlyCalendar(container) {
const year = this.currentMonth.getFullYear();
const month = this.currentMonth.getMonth();
// 해당 월의 첫째 날과 마지막 날
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// 캘린더 시작일 (해당 월 첫째 날이 속한 주의 일요일)
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
// 캘린더 종료일 (해당 월 마지막 날이 속한 주의 토요일)
const endDate = new Date(lastDay);
endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()));
// 이벤트를 날짜별로 그룹화
const eventsByDate = this.getMonthEvents(year, month);
// 오늘 날짜
const today = new Date();
today.setHours(0, 0, 0, 0);
// 월 제목
const monthTitle = `${year}${month + 1}`;
// 요일 헤더
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
// 날짜 셀 생성
let daysHtml = '';
const currentDate = new Date(startDate);
// 로컬 시간 기준 YYYY-MM-DD 형식 반환
const getLocalDateStr = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
while (currentDate <= endDate) {
const dateStr = getLocalDateStr(currentDate);
const isOtherMonth = currentDate.getMonth() !== month;
const isToday = currentDate.getTime() === today.getTime();
const dayEvents = eventsByDate[dateStr] || [];
// 국가 필터 적용
const filteredDayEvents = this.selectedCountry === 'all'
? dayEvents
: dayEvents.filter(e => e.country === this.selectedCountry);
// 최대 4개까지만 표시
const visibleEvents = filteredDayEvents.slice(0, 4);
const moreCount = filteredDayEvents.length - 4;
const eventsHtml = visibleEvents.map(event => {
const stars = '★'.repeat(event.importance);
return `
<div class="day-event importance-${event.importance}" title="${event.time} ${event.name}">
<span class="day-event-time">${event.time}</span>
<span class="day-event-country">${this.getCountryFlag(event.country)}</span>
<span class="day-event-name">${event.name}</span>
<span class="day-event-stars">${stars}</span>
</div>
`;
}).join('');
const moreHtml = moreCount > 0 ? `<div class="day-event-more" data-date="${dateStr}">+${moreCount}개 더보기</div>` : '';
daysHtml += `
<div class="calendar-day ${isOtherMonth ? 'other-month' : ''} ${isToday ? 'today' : ''}">
<div class="day-number">${currentDate.getDate()}</div>
<div class="day-events">
${eventsHtml}
${moreHtml}
</div>
</div>
`;
currentDate.setDate(currentDate.getDate() + 1);
}
container.innerHTML = `
<div class="calendar-grid">
<div class="calendar-grid-header">
<div class="calendar-nav">
<button class="calendar-nav-btn" id="prev-month">&lt; 이전</button>
</div>
<div class="calendar-month-title">${monthTitle}</div>
<div class="calendar-nav">
<button class="calendar-nav-btn" id="next-month">다음 &gt;</button>
</div>
</div>
<div class="calendar-weekdays">
${weekdays.map(day => `<div class="calendar-weekday">${day}</div>`).join('')}
</div>
<div class="calendar-days">
${daysHtml}
</div>
</div>
`;
// 네비게이션 버튼 이벤트
document.getElementById('prev-month')?.addEventListener('click', () => {
this.currentMonth.setMonth(this.currentMonth.getMonth() - 1);
this.renderCalendarList();
});
document.getElementById('next-month')?.addEventListener('click', () => {
this.currentMonth.setMonth(this.currentMonth.getMonth() + 1);
this.renderCalendarList();
});
// 더보기 버튼 클릭 이벤트
document.querySelectorAll('.day-event-more').forEach(btn => {
btn.addEventListener('click', (e) => {
const dateStr = e.target.dataset.date;
this.showDayEventsModal(dateStr, eventsByDate[dateStr] || []);
});
});
}
showDayEventsModal(dateStr, events) {
// 국가 필터 적용
const filteredEvents = this.selectedCountry === 'all'
? events
: events.filter(e => e.country === this.selectedCountry);
const date = new Date(dateStr);
const dateTitle = `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
const eventsHtml = filteredEvents.map(event => {
const stars = '★'.repeat(event.importance);
return `
<div class="modal-event importance-${event.importance}">
<span class="modal-event-time">${event.time}</span>
<span class="modal-event-country">${this.getCountryFlag(event.country)}</span>
<span class="modal-event-name">${event.name}</span>
<span class="modal-event-stars">${stars}</span>
</div>
`;
}).join('');
// 모달 생성
const modal = document.createElement('div');
modal.className = 'events-modal-overlay';
modal.innerHTML = `
<div class="events-modal">
<div class="events-modal-header">
<h3>${dateTitle} 일정</h3>
<button class="events-modal-close">&times;</button>
</div>
<div class="events-modal-body">
${eventsHtml}
</div>
</div>
`;
document.body.appendChild(modal);
// 닫기 이벤트
modal.querySelector('.events-modal-close').addEventListener('click', () => {
modal.remove();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
getMonthEvents(year, month) {
const grouped = {};
this.events.forEach(event => {
const eventDate = new Date(event.date);
// 해당 월 전후 1주일까지 포함 (캘린더에 표시되는 범위)
const eventYear = eventDate.getFullYear();
const eventMonth = eventDate.getMonth();
// 해당 월 또는 인접 월의 이벤트만 포함
if ((eventYear === year && eventMonth === month) ||
(eventYear === year && eventMonth === month - 1) ||
(eventYear === year && eventMonth === month + 1) ||
(eventYear === year - 1 && month === 0 && eventMonth === 11) ||
(eventYear === year + 1 && month === 11 && eventMonth === 0)) {
if (!grouped[event.date]) {
grouped[event.date] = [];
}
grouped[event.date].push(event);
}
});
// 각 날짜의 이벤트를 시간순으로 정렬
Object.keys(grouped).forEach(date => {
grouped[date].sort((a, b) => a.time.localeCompare(b.time));
});
return grouped;
}
}
// Initialize the application
document.addEventListener('DOMContentLoaded', () => {
window.tradingView = new TradingView();
window.economicCalendar = new EconomicCalendar();
});