<?php require_once '/home/www/GNU/_PAGE/head.php'; ?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Volume Heartbeat</title>
<style>
:root {
--bg-primary: #0a0e27;
--bg-secondary: #151932;
--bg-tertiary: #1e2742;
--bg-card: #1a1f3a;
--bg-hover: #252b4a;
--bg-border: #2a3458;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-primary: #3b82f6;
--accent-secondary: #8b5cf6;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--border-color: #2a3458;
--heartbeat-color: #10b981;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, var(--bg-primary) 0%, #0f172a 100%);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Malgun Gothic', 'Roboto', sans-serif;
padding: 0px;
min-height: 100vh;
line-height: 1.6;
}
h2 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
gap: 8px;
margin: 40px 40px 0 40px;
}
#heartbeat-container {
padding: 16px;
background: var(--bg-card);
border-radius: 7px;
border: 1px solid var(--border-color);
box-shadow: var(--shadow-lg);
transition: all 0.3s ease;
margin: 15px 40px 0 40px;
}
#heartbeat-container:hover {
border-color: var(--accent-primary);
box-shadow: 0 12px 24px rgba(59, 130, 246, 0.15);
}
#heartbeat-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
color: var(--text-primary);
}
#heartbeat-title > span {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
}
#heartbeat-info {
font-size: 12px;
color: var(--text-secondary);
padding: 6px 12px;
background: var(--bg-tertiary);
border-radius: 6px;
border: 1px solid var(--border-color);
font-family: 'Courier New', monospace;
font-weight: 500;
}
#heartbeat-controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 13px;
}
#heartbeat-controls > label {
color: var(--text-secondary);
font-weight: 500;
}
#heartbeat-market {
padding: 8px 12px;
font-size: 13px;
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: 8px;
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
outline: none;
}
#heartbeat-market:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
#heartbeat-market:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
#heartbeat-market option {
background: var(--bg-card);
color: var(--text-primary);
padding: 8px;
}
#heartbeat {
width: 100%;
height: 80px;
background: linear-gradient(to bottom, #000000 0%, #0a0e27 100%);
border: 1px solid var(--border-color);
border-radius: 8px;
display: block;
margin-top: 12px;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5);
}
/* 스크롤바 스타일링 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
border: 2px solid var(--bg-secondary);
}
::-webkit-scrollbar-thumb:hover {
background: var(--bg-hover);
}
/* 반응형 디자인 */
@media (max-width: 768px) {
body {
padding: 16px;
}
h2 {
font-size: 20px;
}
#heartbeat-title {
flex-direction: column;
align-items: flex-start;
}
#heartbeat-controls {
width: 100%;
flex-direction: column;
align-items: flex-start;
}
#heartbeat-market {
width: 100%;
}
}
/* 연결 상태 표시용 (추가 가능) */
.connection-status {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
margin-right: 6px;
box-shadow: 0 0 6px var(--success);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>
</head>
<body>
<h2>📈 거래량 심전도 - 미니 풀 버전</h2>
<div id="heartbeat-container">
<div id="heartbeat-title">
<span>거래량 심전도 (Volume Heartbeat) Mini Full</span>
<div id="heartbeat-controls">
<label for="heartbeat-market">감시 코인:</label>
<select id="heartbeat-market">
<option value="KRW-BTC">BTC</option>
<option value="KRW-ETH">ETH</option>
<option value="KRW-XRP">XRP</option>
<option value="KRW-QTUM">QTUM</option>
<option value="KRW-TRUMP">TRUMP</option>
</select>
<span id="heartbeat-info">데이터 대기 중...</span>
</div>
</div>
<canvas id="heartbeat" width="800" height="80"></canvas>
</div>
<script>
// 상수 설정
const HEARTBEAT_POINTS = 120;
const MINUTE_BUFFER_SIZE = 60; // 60초 버퍼 (1분 집계용)
const CLEAR_BUFFER_ON_RECONNECT = false; // WS 재연결 시 버퍼 초기화 여부
const MAX_FPS = 60;
const SPIKE_THRESHOLD = 3.0; // 평균 대비 3배 이상 증가 시 spike 감지
const NO_TRADE_GRAY_THRESHOLD = 2000; // 2초 무거래
const NO_TRADE_FLAT_THRESHOLD = 10000; // 10초 무거래
const CLIP_PERCENTILE = 0.99; // 상위 1% 클리핑
const FIX_BASELINE_TO_MINUTE = false; // 분 기준 기준선 고정 옵션
const DEBUG_MODE = false; // 내부 상태 디버그 토글
const WS_QUALITY_THRESHOLD = 5; // 초당 최소 메시지 수 (이하 시 경고)
const MICRO_TRADE_RATIO = 0.05; // 분 평균 대비 5% 미만 시 미세 거래 구간
let heartbeatMarket = "KRW-BTC";
let heartbeatData = []; // 초 단위 데이터 (표시용)
let minuteBuffer = []; // 60초 버퍼 (집계용)
let lastTickTime = null;
let tickCount1s = 0;
let tickCount5s = 0;
let tickTimestamps = []; // 틱 시간 기록
let spikeMarkers = []; // spike 마커 위치
let spikeHistory = []; // spike 발생 시간 기록
let renderRequested = false;
let lastRenderTime = 0;
let noTradeStartTime = null;
let isTradingHalted = false;
let fixedBaseline = null; // 고정 기준선 (mean/std)
let lastBaselineUpdate = null; // 마지막 기준선 갱신 시간
let wsMessageCount = 0; // WS 수신 메시지 카운트
let wsQualityTimer = null; // WS 품질 체크 타이머
let lastWsQualityCheck = Date.now();
const heartbeatCanvas = document.getElementById('heartbeat');
const hbCtx = heartbeatCanvas.getContext('2d');
const heartbeatInfoEl = document.getElementById('heartbeat-info');
const heartbeatSelect = document.getElementById('heartbeat-market');
// Observer endpoint (필요시 수정)
const OBSERVER_ENDPOINT = '/observer'; // 실제 엔드포인트로 변경 필요
// 디버그 로그
function debugLog(...args) {
if (DEBUG_MODE) {
console.log('[VolumeECG]', ...args);
}
}
// Canvas 크기 조정 (반응형)
function resizeCanvas() {
const container = heartbeatCanvas.parentElement;
const containerWidth = container.clientWidth;
heartbeatCanvas.width = containerWidth;
heartbeatCanvas.height = 80;
requestRender();
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
heartbeatSelect.addEventListener('change', () => {
heartbeatMarket = heartbeatSelect.value;
heartbeatData = [];
minuteBuffer = [];
spikeMarkers = [];
spikeHistory = [];
tickTimestamps = [];
lastTickTime = null;
noTradeStartTime = null;
isTradingHalted = false;
fixedBaseline = null;
lastBaselineUpdate = null;
requestRender();
});
// 메모리 정리 (보조 배열 정리)
function cleanupMemory() {
const now = Date.now();
const maxAge = 120000; // 2분
// 오래된 spike 마커 제거
while (spikeMarkers.length > 0 && now - spikeMarkers[0].ts > 60000) {
spikeMarkers.shift();
}
// 오래된 spike 기록 제거
while (spikeHistory.length > 0 && spikeHistory[0] < now - 60000) {
spikeHistory.shift();
}
// 오래된 틱 타임스탬프 제거
while (tickTimestamps.length > 0 && now - tickTimestamps[0] > 5000) {
tickTimestamps.shift();
}
debugLog('메모리 정리 완료', {
spikeMarkers: spikeMarkers.length,
spikeHistory: spikeHistory.length,
tickTimestamps: tickTimestamps.length
});
}
// 틱 속도 계산
function updateTickSpeed(ts) {
const now = ts || Date.now();
tickTimestamps.push(now);
// 5초 이상 오래된 틱 제거
while (tickTimestamps.length > 0 && now - tickTimestamps[0] > 5000) {
tickTimestamps.shift();
}
// 1초 틱 수 계산
const recent1s = tickTimestamps.filter(t => now - t <= 1000).length;
// 5초 틱 수 계산
const recent5s = tickTimestamps.length;
tickCount1s = recent1s;
tickCount5s = recent5s;
}
// 무거래 감지
function checkNoTrade(ts) {
if (lastTickTime === null) {
noTradeStartTime = null;
isTradingHalted = false;
return;
}
const elapsed = ts - lastTickTime;
if (elapsed >= NO_TRADE_FLAT_THRESHOLD) {
if (!isTradingHalted) {
isTradingHalted = true;
requestRender();
}
} else if (elapsed < NO_TRADE_GRAY_THRESHOLD) {
if (isTradingHalted) {
isTradingHalted = false;
noTradeStartTime = null;
requestRender();
}
}
}
// 거래량 압축 보호 (상위 1% 클리핑)
function clipExtremeValues(values) {
if (values.length < 10) return values;
const sorted = [...values].sort((a, b) => a - b);
const clipIndex = Math.floor(sorted.length * CLIP_PERCENTILE);
const clipValue = sorted[clipIndex];
return values.map(v => Math.min(v, clipValue));
}
// Spike 감지 및 전송
function detectSpike(currentVolume, recentAvg) {
if (!recentAvg || recentAvg <= 0 || currentVolume <= 0) return false;
if (isNaN(currentVolume) || isNaN(recentAvg)) return false;
const ratio = currentVolume / recentAvg;
if (ratio >= SPIKE_THRESHOLD) {
const now = Date.now();
spikeHistory.push(now);
// Observer endpoint로 전송
try {
fetch(OBSERVER_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'volume_spike',
market: heartbeatMarket,
volume: currentVolume,
avg_volume: recentAvg,
ratio: ratio,
timestamp: now
})
}).catch(err => {
if (DEBUG_MODE) console.error('Observer 전송 실패:', err);
});
} catch (e) {
if (DEBUG_MODE) console.error('Observer 전송 오류:', e);
}
return true;
}
return false;
}
// 1분 집계 계산 (mean/std용)
function calculateMinuteStats() {
if (minuteBuffer.length === 0) return { mean: 0, std: 0 };
let sum = 0;
let sumSq = 0;
let validCount = 0;
for (let i = 0; i < minuteBuffer.length; i++) {
const v = minuteBuffer[i].volume || 0;
if (isNaN(v) || v < 0) continue;
sum += v;
sumSq += v * v;
validCount++;
}
if (validCount === 0) return { mean: 0, std: 0 };
const mean = sum / validCount;
const variance = Math.max(0, sumSq / validCount - mean * mean);
const std = Math.sqrt(variance);
return { mean, std };
}
// 거래량 방향성 계산 (직전 분 평균 대비)
function calculateVolumeDirection() {
if (heartbeatData.length < 10 || minuteBuffer.length < 10) return null;
const recent10 = heartbeatData.slice(-10);
const recentAvg = recent10.reduce((sum, d) => sum + (d.volume || 0), 0) / recent10.length;
const minuteStats = calculateMinuteStats();
const minuteAvg = minuteStats.mean || 0;
if (minuteAvg <= 0) return null;
const ratio = recentAvg / minuteAvg;
if (ratio > 1.1) return 'up'; // 10% 이상 증가
if (ratio < 0.9) return 'down'; // 10% 이상 감소
return 'neutral';
}
// 미세 거래량 구간 감지
function detectMicroTrade() {
if (heartbeatData.length < 10 || minuteBuffer.length < 10) return false;
const now = Date.now();
const recent10s = heartbeatData.filter(d => now - d.ts <= 10000);
if (recent10s.length === 0) return false;
const recent10sAvg = recent10s.reduce((sum, d) => sum + (d.volume || 0), 0) / recent10s.length;
const minuteStats = calculateMinuteStats();
const minuteAvg = minuteStats.mean || 0;
if (minuteAvg <= 0) return false;
return recent10sAvg < minuteAvg * MICRO_TRADE_RATIO;
}
// 거래량 리듬 지수 계산 (5초 vs 30초)
function calculateRhythmIndex() {
if (heartbeatData.length < 30) return null;
const now = Date.now();
const recent5s = heartbeatData.filter(d => now - d.ts <= 5000);
const recent30s = heartbeatData.filter(d => now - d.ts <= 30000);
if (recent5s.length === 0 || recent30s.length === 0) return null;
const vol5s = recent5s.reduce((sum, d) => sum + (d.volume || 0), 0);
const vol30s = recent30s.reduce((sum, d) => sum + (d.volume || 0), 0);
if (vol30s <= 0) return null;
return vol5s / vol30s;
}
// 스파이크 누적 카운트 (1분)
function getSpikeCount1m() {
const now = Date.now();
return spikeHistory.filter(ts => now - ts <= 60000).length;
}
// 시간 정렬 보호: ts 역전 체크
function isValidTimestamp(ts) {
if (heartbeatData.length === 0) return true;
const lastTs = heartbeatData[heartbeatData.length - 1].ts;
return ts >= lastTs;
}
// 데이터 업데이트
function updateHeartbeat(ts, volume) {
// 데이터 검증
if (!ts || !volume || volume <= 0 || isNaN(volume)) return;
// 시간 정렬 보호
if (!isValidTimestamp(ts)) {
debugLog('시간 역전 데이터 무시:', ts);
return;
}
const now = ts || Date.now();
lastTickTime = now;
// 무거래 상태 해제
if (isTradingHalted) {
isTradingHalted = false;
noTradeStartTime = null;
}
// 1분 버퍼 업데이트
minuteBuffer.push({ ts: now, volume: volume });
while (minuteBuffer.length > MINUTE_BUFFER_SIZE) {
minuteBuffer.shift();
}
// 기준선 고정 옵션 처리
if (FIX_BASELINE_TO_MINUTE) {
const currentMinute = Math.floor(now / 60000);
if (fixedBaseline === null || lastBaselineUpdate !== currentMinute) {
fixedBaseline = calculateMinuteStats();
lastBaselineUpdate = currentMinute;
debugLog('기준선 갱신:', fixedBaseline);
}
}
// 초 단위 데이터 업데이트 (표시용)
heartbeatData.push({ ts: now, volume: volume, isGray: false });
while (heartbeatData.length > HEARTBEAT_POINTS) {
heartbeatData.shift();
}
// 최근 2초 데이터를 회색 처리
for (let i = heartbeatData.length - 1; i >= 0; i--) {
const elapsed = now - heartbeatData[i].ts;
heartbeatData[i].isGray = elapsed >= NO_TRADE_GRAY_THRESHOLD && elapsed < NO_TRADE_FLAT_THRESHOLD;
}
// Spike 감지
if (heartbeatData.length >= 10) {
const recent10 = heartbeatData.slice(-10);
const recentAvg = recent10.reduce((sum, d) => sum + (d.volume || 0), 0) / recent10.length;
if (detectSpike(volume, recentAvg)) {
spikeMarkers.push({ index: heartbeatData.length - 1, ts: now });
}
}
// 틱 속도 업데이트
updateTickSpeed(now);
// 메모리 정리 (주기적)
if (heartbeatData.length % 50 === 0) {
cleanupMemory();
}
requestRender();
}
// 렌더 요청 (FPS 제한)
function requestRender() {
if (!renderRequested) {
renderRequested = true;
requestAnimationFrame(() => {
const now = performance.now();
if (now - lastRenderTime >= 1000 / MAX_FPS) {
lastRenderTime = now;
drawHeartbeat();
}
renderRequested = false;
});
}
}
// 무거래 체크 (주기적)
setInterval(() => {
const now = Date.now();
checkNoTrade(now);
requestRender();
}, 500);
function drawHeartbeat() {
const ctx = hbCtx;
const w = heartbeatCanvas.width;
const h = heartbeatCanvas.height;
ctx.clearRect(0, 0, w, h);
if (heartbeatData.length < 2) {
ctx.fillStyle = "#64748b";
ctx.font = "12px Arial";
ctx.textAlign = "center";
ctx.fillText("데이터 수집 중...", w / 2, h / 2);
heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 데이터 수집 중";
return;
}
// 무거래 평선 처리
if (isTradingHalted) {
const meanStats = FIX_BASELINE_TO_MINUTE && fixedBaseline ? fixedBaseline : calculateMinuteStats();
const meanY = h / 2;
ctx.strokeStyle = "#64748b";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, meanY);
ctx.lineTo(w, meanY);
ctx.stroke();
ctx.fillStyle = "#ef4444";
ctx.font = "11px Arial";
ctx.textAlign = "center";
ctx.fillText("거래 정지 구간", w / 2, meanY - 10);
const spikeCount = getSpikeCount1m();
heartbeatInfoEl.textContent = `집계: 1분 / 표현: 초 | 거래 정지 구간 | 틱: ${tickCount1s}/5s | 스파이크: ${spikeCount}/1m`;
return;
}
// 값 추출 및 검증
const volumes = heartbeatData.map(d => {
const v = d.volume || 0;
return isNaN(v) || v < 0 ? 0 : v;
}).filter(v => v > 0);
if (volumes.length === 0) {
heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 유효 데이터 없음";
return;
}
// 압축 보호: 상위 1% 클리핑
const clippedVolumes = clipExtremeValues(volumes);
let minV = Math.min(...clippedVolumes);
let maxV = Math.max(...clippedVolumes);
if (maxV <= 0 || isNaN(maxV)) maxV = 1;
if (minV < 0 || isNaN(minV)) minV = 0;
const range = maxV - minV || 1;
// 1분 집계 통계 (기준선용)
let minuteStats;
if (FIX_BASELINE_TO_MINUTE && fixedBaseline) {
minuteStats = fixedBaseline;
} else {
minuteStats = calculateMinuteStats();
}
const mean = minuteStats.mean || 0;
const std = minuteStats.std || 0;
// 추가 계산
const direction = calculateVolumeDirection();
const isMicroTrade = detectMicroTrade();
const rhythmIndex = calculateRhythmIndex();
const spikeCount = getSpikeCount1m();
// 정보 표시 문자열 생성
let infoParts = ['집계: 1분 / 표현: 초', `틱: ${tickCount1s}/5s`];
if (isMicroTrade) {
infoParts.push('미세 거래 구간');
}
if (rhythmIndex !== null && !isNaN(rhythmIndex)) {
infoParts.push(`RI: ${rhythmIndex.toFixed(2)}`);
}
infoParts.push(`평균≈${mean.toFixed(4)}`, `σ≈${std.toFixed(4)}`);
infoParts.push(`스파이크: ${spikeCount}/1m`);
heartbeatInfoEl.textContent = infoParts.join(' | ');
// 그리기
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.shadowBlur = 4;
ctx.shadowColor = "rgba(16, 185, 129, 0.5)";
// 방향성에 따른 명도 조절
let brightness = 1.0;
if (direction === 'up') {
brightness = 1.1; // 10% 밝게
} else if (direction === 'down') {
brightness = 0.9; // 10% 어둡게
}
// 그라데이션 생성 (명도 조절)
const createGradient = () => {
const grad = ctx.createLinearGradient(0, 0, 0, h);
const applyBrightness = (r, g, b) => {
return `rgb(${Math.round(Math.min(255, r * brightness))}, ${Math.round(Math.min(255, g * brightness))}, ${Math.round(Math.min(255, b * brightness))})`;
};
grad.addColorStop(0, applyBrightness(16, 185, 129)); // #10b981
grad.addColorStop(0.5, applyBrightness(5, 150, 105)); // #059669
grad.addColorStop(1, applyBrightness(4, 120, 87)); // #047857
return grad;
};
// 메인 라인 (초 단위)
ctx.beginPath();
let hasPath = false;
let currentGradient = null;
for (let i = 0; i < heartbeatData.length; i++) {
const x = (i / (heartbeatData.length - 1)) * (w - 2) + 1;
const v = heartbeatData[i].volume || 0;
if (isNaN(v) || v < 0) continue;
const clippedV = Math.min(v, maxV);
const yRatio = (clippedV - minV) / range;
const y = h - 1 - yRatio * (h - 2);
if (!hasPath) {
ctx.moveTo(x, y);
hasPath = true;
} else {
ctx.lineTo(x, y);
}
// 회색 처리 (2초 이상 무거래)
if (heartbeatData[i].isGray) {
ctx.strokeStyle = "#64748b";
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y);
hasPath = true;
currentGradient = null;
} else {
if (!currentGradient) {
currentGradient = createGradient();
}
ctx.strokeStyle = currentGradient;
}
}
if (hasPath) {
ctx.stroke();
}
// Spike 마커 표시
ctx.fillStyle = "#ef4444";
ctx.shadowBlur = 6;
ctx.shadowColor = "rgba(239, 68, 68, 0.8)";
for (let marker of spikeMarkers) {
if (marker.index < heartbeatData.length) {
const x = (marker.index / (heartbeatData.length - 1)) * (w - 2) + 1;
const v = heartbeatData[marker.index].volume || 0;
if (isNaN(v) || v < 0) continue;
const clippedV = Math.min(v, maxV);
const yRatio = (clippedV - minV) / range;
const y = h - 1 - yRatio * (h - 2);
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
}
}
// 그림자 초기화
ctx.shadowBlur = 0;
}
// WebSocket 품질 감시
function checkWsQuality() {
const now = Date.now();
const elapsed = (now - lastWsQualityCheck) / 1000; // 초 단위
if (elapsed >= 1) {
const msgPerSec = wsMessageCount / elapsed;
wsMessageCount = 0;
lastWsQualityCheck = now;
debugLog('WS 품질:', msgPerSec.toFixed(2), 'msg/s');
if (msgPerSec < WS_QUALITY_THRESHOLD && heartbeatData.length > 0) {
const currentText = heartbeatInfoEl.textContent;
if (!currentText.includes('데이터 지연 가능')) {
heartbeatInfoEl.textContent = currentText + ' | [데이터 지연 가능]';
}
}
}
}
// WebSocket
let ws = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
// WS 품질 감시 시작
wsQualityTimer = setInterval(checkWsQuality, 1000);
lastWsQualityCheck = Date.now();
function connectWS() {
try {
ws = new WebSocket("wss://api.upbit.com/websocket/v1");
ws.onopen = () => {
reconnectAttempts = 0;
wsMessageCount = 0;
lastWsQualityCheck = Date.now();
ws.send(JSON.stringify([
{ ticket: "volume_only" },
{ type: "ticker", codes: ["KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-QTUM", "KRW-TRUMP"] }
]));
heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 연결됨: 데이터 수집 중...";
debugLog('WS 연결됨');
};
ws.onmessage = (event) => {
wsMessageCount++;
event.data.arrayBuffer().then(buffer => {
const decoder = new TextDecoder("utf-8");
const json = decoder.decode(buffer);
try {
const data = JSON.parse(json);
const market = data.cd || data.code || "";
const volume = Number(data.tv || data.trade_volume || 0);
const ts = Date.now();
// 데이터 검증
if (!market || !volume || volume <= 0 || isNaN(volume)) {
return;
}
if (market === heartbeatMarket) {
updateHeartbeat(ts, volume);
}
} catch (e) {
if (DEBUG_MODE) console.error("데이터 파싱 오류:", e);
}
}).catch(err => {
if (DEBUG_MODE) console.error("Buffer 디코딩 오류:", err);
});
};
ws.onerror = (error) => {
if (DEBUG_MODE) console.error("WebSocket 오류:", error);
heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 연결 오류 발생";
};
ws.onclose = () => {
heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 연결 끊김: 재연결 시도 중...";
debugLog('WS 연결 끊김');
// 재연결 시 버퍼 초기화 옵션
if (CLEAR_BUFFER_ON_RECONNECT) {
heartbeatData = [];
minuteBuffer = [];
spikeMarkers = [];
spikeHistory = [];
tickTimestamps = [];
lastTickTime = null;
fixedBaseline = null;
lastBaselineUpdate = null;
debugLog('버퍼 초기화됨');
}
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
setTimeout(() => {
connectWS();
}, 3000 * reconnectAttempts);
} else {
heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 연결 실패: 페이지를 새로고침하세요";
}
};
} catch (e) {
if (DEBUG_MODE) console.error("WebSocket 연결 오류:", e);
heartbeatInfoEl.textContent = "집계: 1분 / 표현: 초 | 연결 실패";
}
}
connectWS();
</script>
</body>
</html>